
應用開始變大之後,handler 裡常常需要各種 service 和 repository,如果每次都手動 new,會很難測試 (沒辦法替換成 mock),也很難管理生命週期 (singleton、connection pool),而且依賴關係會散落的到處都是
這篇會在 Relix 內建一個極簡 DI 容器,不是 Spring 那種全自動掃描注入,而是手動宣告、型別安全、夠用就好
val app = Relix { }
app.install(ContentNegotiation) { json() } // ok(List<String>) 要走 JSON 序列化
app.services {
singleton<UserRepository> { InMemoryUserRepository() }
singleton<UserService> { UserService(resolve()) }
}
app.routing {
get("/users") {
val service = resolve<UserService>()
ok(service.list())
}
}
singleton<UserRepository> { ... } 宣告一個 binding,resolve() 在 provider 內部可以拿其他依賴,handler 裡的 resolve<UserService>() 透過 RelixCall 委派到 application 的 container
容器本身不碰 HTTP,建好直接呼叫 singleton()、resolve() 就測得到,只有最後一個「handler 拿得到服務」要走 TestKit,四種行為都在同一組,測試檔案放 ServiceContainerTest.kt
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotSame
import kotlin.test.assertSame
import kotlin.test.assertFailsWith
interface UserRepository {
fun findAll(): List<String>
}
class InMemoryUserRepository : UserRepository {
override fun findAll() = listOf("Alice", "Bob")
}
class UserService(private val repo: UserRepository) {
fun list() = repo.findAll()
}
class ServiceContainerTest {
@Test
fun `singleton returns same instance`() {
val container = ServiceContainer()
container.singleton<UserRepository> { InMemoryUserRepository() }
val a = container.resolve<UserRepository>()
val b = container.resolve<UserRepository>()
assertSame(a, b)
}
@Test
fun `factory returns different instances`() {
val container = ServiceContainer()
container.factory<UserRepository> { InMemoryUserRepository() }
val a = container.resolve<UserRepository>()
val b = container.resolve<UserRepository>()
assertNotSame(a, b)
}
@Test
fun `resolve chains dependencies`() {
val container = ServiceContainer()
container.singleton<UserRepository> { InMemoryUserRepository() }
container.singleton<UserService> { UserService(resolve()) }
val service = container.resolve<UserService>()
assertEquals(listOf("Alice", "Bob"), service.list())
}
@Test
fun `resolve missing binding throws`() {
val container = ServiceContainer()
assertFailsWith<IllegalStateException> {
container.resolve<UserService>()
}
}
@Test
fun `handler can resolve services`() {
val app = RelixApplication()
app.services {
singleton<UserRepository> { InMemoryUserRepository() }
singleton<UserService> { UserService(resolve()) }
}
app.routing {
get("/users") {
val service = resolve<UserService>()
ok(service.list().joinToString(","))
}
}
val testKit = RelixTestKit(app)
val response = testKit.handleRequest("GET", "/users")
assertEquals(200, response.statusCode)
assertEquals("Alice,Bob", response.body.toString(Charsets.UTF_8))
}
}
容器跟框架既有的檔案沒有相依關係,開一個 ServiceContainer.kt 放它
import kotlin.reflect.KClass
class ServiceContainer {
@PublishedApi
internal val singletons = mutableMapOf<KClass<*>, Lazy<Any>>()
@PublishedApi
internal val factories = mutableMapOf<KClass<*>, () -> Any>()
inline fun <reified T : Any> singleton(noinline provider: ServiceContainer.() -> T) {
singletons[T::class] = lazy { provider() }
}
inline fun <reified T : Any> factory(noinline provider: ServiceContainer.() -> T) {
factories[T::class] = { provider() }
}
inline fun <reified T : Any> resolve(): T {
return resolveByClass(T::class) as T
}
fun resolveByClass(klass: KClass<*>): Any {
singletons[klass]?.let { return it.value }
factories[klass]?.let { return it() }
throw IllegalStateException(
"No binding found for ${klass.simpleName}. " +
"Did you forget to register it with singleton<${klass.simpleName}> { ... }?"
)
}
}
singletons 用 Lazy<Any> 包,第一次 resolve 時才執行 provider,之後回同一個 instance,factories 每次呼叫都執行 provider
key 是 KClass<*>,這裡用 KClass 基本上夠用了,但有個限制,singleton<List<String>> 和 singleton<List<Int>> 的 KClass 都是 List::class,會衝突,真要支援泛型需要用 KType
resolve() 先找 singleton,再找 factory,都找不到丟 IllegalStateException,錯誤訊息告訴你是哪個型別沒註冊
singleton<UserService> { UserService(resolve()) }
為什麼 provider 裡面可以呼叫 resolve() ? 因為 provider 的簽名是 ServiceContainer.() -> T,receiver 是 ServiceContainer,在 lambda 裡面 this 就是 container,所以可以直接打 resolve<UserRepository>()
這讓依賴組裝變得自然,UserService 需要 UserRepository,provider 直接 resolve() 拿到,container 會根據 singleton 的 Lazy 確保 UserRepository 只建立一次
容器要掛在 application 上才有人拿得到,調整 RelixApplication.kt,補一個欄位跟一個方法
val container = ServiceContainer()
fun services(block: ServiceContainer.() -> Unit) {
container.block()
}
services { } 把 ServiceContainer 當 receiver 傳進去,在裡面可以呼叫 singleton<T>() 和 factory<T>()
這段要補到 ServiceContainer.kt,寫在 class 外面,它是 top-level function
inline fun <reified T : Any> RelixCall.resolve(): T {
return application.container.resolve<T>()
}
handler 的 resolve<UserService>() 委派到 application.container.resolve<UserService>(),handler 不需要知道 container 的存在,直接用就好
這裡用 extension function 而不是 member,是刻意的,DI 不是 RelixCall 的核心責任,寫成 extension 就能跟 ServiceContainer 放在同一個檔案,RelixCall 不必為了它多一個依賴
第 22 篇的 unprocessableEntity()、第 24 篇的 serveStaticFile() 也是同一個理由,反過來說,第 20 篇的 ok() 和第 21 篇的 receive() 寫成 member,是因為它們要跟字串版本做多載解析,放在同一個類別裡規則最單純
get("/users") {
val service = resolve<UserService>()
ok(service.list())
}
第 15 篇的 loggingMiddleware(logger) 是把 logger 從外面傳進 middleware,middleware 有得用,handler 裡想印一行 log 就沒辦法,只能自己 new 一個,或是靠全域變數傳
有了容器就不用這樣繞,logger 跟其他服務一樣註冊進去就好
val app = Relix {
logLevel = LogLevel.INFO
}
val logger = ConsoleLogger(app.config.logLevel)
app.use(loggingMiddleware(logger))
app.services {
singleton<RelixLogger> { logger }
}
這裡刻意在 services { } 外面先建好 logger 再註冊進去,而不是寫成 singleton<RelixLogger> { ConsoleLogger(app.config.logLevel) },因為 middleware 跟 handler 要拿到同一個實例,而 app.use() 那一刻就要有 logger,binding 卻還沒註冊,resolve 不到就只好自己再 new 一個,兩邊變成兩個不同的物件,硬要走 provider 那條路也不是不行,就得先 app.services { } 再 app.use(loggingMiddleware(app.container.resolve())),多綁一層順序,註冊一個已經存在的實例是容器最單純的用法,provider 直接回傳它就好
handler 裡的用法跟其他服務沒有兩樣
post("/orders") {
val logger = resolve<RelixLogger>()
val orderService = resolve<OrderService>()
val order = receive<CreateOrderRequest>()
logger.info("Creating order for user ${order.userId}")
ok(orderService.create(order))
}
binding 的型別寫 RelixLogger 而不是 ConsoleLogger,是為了讓 handler 只依賴介面。測試時把容器裡換成 FakeLogger,handler 完全不用改
app.services {
singleton<RelixLogger> { FakeLogger() }
}
測試裡 resolve<RelixLogger>() 拿到的就是 FakeLogger,可以直接檢查 messages 的內容,middleware 那邊要一起換,loggingMiddleware(fakeLogger) 傳同一個進去就好
這正是第 15 篇把 logger 抽成介面的回報,那時候只有 middleware 受惠,現在整個應用層都吃得到
測試驗的是「註冊了就 resolve 得到」,真的跑起來才看得出 singleton 跟 factory 差在哪,還有依賴少註冊一個的時候會死在什麼地方
fun main() {
val app = Relix {
port = 8080
logLevel = LogLevel.INFO
}
app.install(ContentNegotiation) { json() }
val logger = ConsoleLogger(app.config.logLevel)
app.use(loggingMiddleware(logger))
app.use(errorHandlingMiddleware(development = true, logger = logger))
app.services {
singleton<RelixLogger> { logger }
singleton<UserRepository> { InMemoryUserRepository() }
singleton<UserService> { UserService(resolve()) }
factory<RequestId> { RequestId(java.util.UUID.randomUUID().toString().take(8)) }
}
app.routing {
get("/users") {
val service = resolve<UserService>()
ok(service.list())
}
get("/identity") {
val service = resolve<UserService>()
val requestId = resolve<RequestId>()
ok("service=${System.identityHashCode(service)} requestId=${requestId.value}")
}
get("/log") {
val log = resolve<RelixLogger>()
log.info("handler logger = ${System.identityHashCode(log)}")
ok("logger=${System.identityHashCode(log)} middlewareLogger=${System.identityHashCode(logger)}")
}
get("/orders") {
val orderService = resolve<OrderService>()
ok("never reached: $orderService")
}
}
app.start()
}
這個 main 用到五個型別,來源不太一樣,先交代清楚,不然編譯不過
UserRepository、InMemoryUserRepository、UserService 前面是宣告在 ServiceContainerTest.kt 裡的,那是為了讓測試自己帶著 fixture,真的要跑起來的時候它們屬於正式程式碼,搬到 main.kt 去,測試那邊的宣告跟著刪掉,理由跟第 21 篇的 CreateUserRequest、第 22 篇的 RegisterRequest 一樣,同 package 兩邊各留一份雖然編譯得過 (test 的宣告會遮蔽 main 的),但兩份定義不同步的時候很難查
interface UserRepository {
fun findAll(): List<String>
}
class InMemoryUserRepository : UserRepository {
override fun findAll() = listOf("Alice", "Bob")
}
class UserService(private val repo: UserRepository) {
fun list() = repo.findAll()
}
另外兩個是為了這一節才加的,RequestId 拿來看 factory 每次是不是真的建新的,OrderService 故意不註冊,等一下要看它怎麼死,兩個都只是空殼,跟上面三個放在一起
class RequestId(val value: String)
class OrderService
依賴鏈真的接起來了
curl -i localhost:8080/users
HTTP/1.1 200 OK
Content-type: application/json; charset=utf-8
Content-length: 15
["Alice","Bob"]
UserService 的 provider 裡那個 resolve() 沒有指定型別,是靠建構子參數的位置推出來要 UserRepository 的,這條線在測試裡看起來就是 assertEquals,實際跑一次才知道它從 HTTP 那一端也走得通
singleton 每次都是同一個,factory 每次都是新的
curl localhost:8080/identity
curl localhost:8080/identity
service=1579219795 requestId=49bfaf9a
service=1579219795 requestId=accf93e5
兩次 request 拿到的 UserService 是同一個物件,Lazy 只算了一次,RequestId 則是每次 resolve 都跑一次 provider,所以兩個值不一樣,這正是 singleton 跟 factory 的分界,前面 assertSame 跟 assertNotSame 講的就是這件事
middleware 跟 handler 拿到同一個 logger
curl localhost:8080/log
logger=1736408392 middlewareLogger=1736408392
[Relix] handler logger = 1736408392
[Relix] GET /log -> 200 (3ms)
兩個數字一樣,前面說「刻意先建好 logger 再註冊進去」就是為了這個結果,如果 binding 寫成 singleton<RelixLogger> { ConsoleLogger(app.config.logLevel) },這兩個數字會不一樣,log 看起來還是正常的,但你手上其實有兩個 logger
少註冊一個依賴
curl -i localhost:8080/orders
HTTP/1.1 500 Internal Server Error
Content-type: text/plain; charset=utf-8
Content-length: 43
Internal Server Error
IllegalStateException
client 拿到的就這兩行,第二行的 IllegalStateException 是因為 main 裡開了 development = true,第 16 篇說過,這個模式只多回一個 class 名稱,原始 message 跟 stack trace 都不會出去,所以 client 這邊看不出是哪個型別沒註冊,真正的線索在 server
[Relix ERROR] Unhandled exception
java.lang.IllegalStateException: No binding found for OrderService. Did you forget to register it with singleton<OrderService> { ... }?
at ServiceContainer.resolveByClass(ServiceContainer.kt:32)
at MainKt$main$3$4.invokeSuspend(main.kt:68)
at ErrorHandlingMiddlewareKt$errorHandlingMiddleware$1.invokeSuspend(ErrorHandlingMiddleware.kt:12)
at PipelineKt$buildPipeline$1.invokeSuspend(Pipeline.kt:24)
at LoggingMiddlewareKt$loggingMiddleware$1.invokeSuspend(LoggingMiddleware.kt:14)
at RelixApplication.handle(RelixApplication.kt:105)
...
每個 suspend lambda 都會多出兩個 invoke 的框架,kotlinx.coroutines 那一大段也一併省掉了,留下來的這幾行才是重點
No binding found 那句在這裡才顯得有價值,它把型別名稱跟該補的那行程式碼一起講完了,下一個框架直接指到 main.kt 是哪一行在 resolve,再往下就是第 16 篇那張 middleware 巢狀圖的實體,logging 在外、error handling 在內,中間夾著 buildPipeline
同時也看到這個容器最大的弱點,OrderService 沒註冊這件事,是有人打了 /orders 才發現的,server 啟動的時候一切正常,Relix started on 0.0.0.0:8080 照樣印,沒有人事先檢查依賴圖完不完整,這條路由可能上線三天都沒人走到
為什麼不自動掃描 class path ?
Spring 用 annotation + class path scanning 自動發現 bean,這需要 reflection 掃描整個 class path,啟動慢、debug 難 (你不知道某個 bean 是從哪裡被掃描進來的),手動宣告 singleton<T> { } 比較囉唆,但依賴關係一目了然,這裡的選擇明確勝過魔法
singleton 的 lazy 初始化順序會不會有問題 ?
Lazy 預設使用 LazyThreadSafetyMode.SYNCHRONIZED,第一次 resolve 時觸發初始化,若 A 與 B 互相依賴,同一個 thread 通常會不斷遞迴直到 StackOverflowError,跨 thread 的初始化才可能互相等待,這裡不偵測循環依賴,正式容器應維護解析中的型別 stack,發現重複時立刻回報清楚錯誤
要加偵測其實不難,骨架大概是這樣
private val resolving = ThreadLocal.withInitial { mutableSetOf<KClass<*>>() }
fun <T : Any> resolve(klass: KClass<T>): T {
val visiting = resolving.get()
if (klass in visiting) {
throw IllegalStateException("Cycle detected: ${visiting.joinToString(" -> ")} -> ${klass.simpleName}")
}
visiting += klass
try {
return providers[klass]!!.invoke() as T
} finally {
visiting -= klass
}
}
這個做法以 thread-local 集合記錄正在解析的型別,重複出現時立刻拋例外,完整容器還應把依賴鏈放進錯誤訊息,編譯期 DI 則能更早檢查整張依賴圖
為什麼不支援 scope (per-request binding) ?
per-request scope 需要在每個 request 開始時建立一個子 container,結束時銷毀,對於這裡來說太複雜,如果你需要 per-request 的東西,放在 CallContext 裡就好 (第 12 篇的機制)
上面那個「上線三天才發現少一個 binding」不是實作沒寫好,是這種設計本來就會這樣,ServiceContainer 全部的邏輯不到三十行,看得懂 DI 在做什麼綽綽有餘,拿去接真的專案就差得遠了
差在哪,前面已經散著講過幾個,這裡一次列清楚
Lazy 是等到第一次 resolve 才動KClass,List<String> 跟 List<Int> 會撞在一起,也沒有 qualifier 可以區分同一個介面的兩個實作 (主資料庫跟唯讀複本這種)close(),容器完全不管ThreadLocal 只是骨架,沒有真的裝進去mutableMap,啟動階段一次註冊完、之後只讀還好,邊跑邊註冊就不安全了Kotlin 這邊要挑現成的,Koin 是純 Kotlin 的 runtime DI,DSL 跟我們這個很像但完整得多,Kodein 也是同一類,要編譯期保證就往 Dagger 或 Hilt 走,Ktor 從 3.2.0 開始自己內建了 DI,DSL 是 dependencies { provide<T> { } },取用寫 dependencies.resolve<T>() 或用 property delegation 的 by dependencies,key 是一個 DependencyKey,裡面除了完整的型別資訊還有一個 name,所以泛型不會撞、同型別也分得出兩個實作,它還做了 covariant 解析 (註冊 ArrayList<String>,要 List<String> 也拿得到)、suspend 的依賴初始化,以及 AutoCloseable 的自動關閉,它還會在 ApplicationModulesLoaded 的時候把整張圖驗過一遍,少一個就丟 DependencyInjectionException,前面那個「上線三天才發現」的情境在它身上不會發生,這七條它幾乎都補上了
話說回來,這不到三十行的程式碼本身不是重點,它的用處是讓 DI 沒有神祕感,容器就是一個 map,key 是型別,value 是「怎麼生出這個東西」的一段程式,resolve 就是查表加上呼叫,Spring 的 @Autowired 跟 Koin 的 by inject() 底下做的是同一件事,剩下的差別就是上面列的那些
ServiceContainer 用 KClass 當 key,Lazy<Any> 實作 singleton,普通 lambda 實作 factory,provider 的 receiver 是 container 本身,所以可以在裡面 resolve() 其他依賴,handler 透過 RelixCall.resolve<T>() 委派到 application 的 container,手動宣告比自動掃描囉唆,但依賴關係清楚可追蹤,這個容器是拿來看懂 DI 的原理的,正式專案還是交給 Koin、Dagger 或 Ktor 自己內建的那套
下一篇做 Status Pages,把第 16 篇的最小 error handling 升級成可客製的錯誤映射,統一輸出格式,避免洩漏敏感資訊
同步刊登於 Blog
圖片來源:AI 產生